31. Project 2048#

In this project, you will develop the 2048 game in Python using a functional (without class) approach and two libraries for the graphical interface: rich to get a nice display in console and readchar for the keyboard input.

31.1. Objectives#

  • Understand functional programming in Python

  • Work with data structures

  • Use the Blessed library to create an interactive terminal interface

  • Decompose a problem into simple and testable functions

31.2. 2048 Game Rules Review#

On a 4x4 grid, a new tile (2 or 4) appears randomly each turn (2 appears with 90% of chance, 4 with 10%). The player can move all tiles in four directions (up, down, left, right). Two adjacent tiles of the same value merge into one (double their value). The game ends when no moves are possible. The goal is to reach tile 2048 (and continue beyond).

You can test the game at this address: 2048.

32. Instructions#

You must follow the tutorial for this project. It will provide you with a framework, particularly for the MVC pattern (Model, View, Controller). Follow this framework meticulously. The minimum objective of the project is to achieve a playable version with a text-based interface (see part 4). For more advanced students, it will be possible to add optional features (Tkinter interface, Pygame, AI, game saving, high scores, etc.) to improve your final grade.

33. Part 1 - Model#

The model will consist of pure functions that manipulate the game state, represented by a dictionary state.

33.1. Step 1.1: Data Structure and Initialization#

Goal: Create functions to initialize and manipulate the game state.

Game State Structure:

{
    'grid': [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    'score': 0,
    'game_over': False,
    'size': 4
}

To Implement:

def create_game_state(size=4):
    """
    Creates a new initial game state.

    Args:
        size (int): Grid size (default 4)

    Returns:
        dict: Initial game state
    """
    pass

Tests to Perform:

Use the following code to test your code. Always remove the tests after.

state = create_game_state()
print(state["grid"])  # -> [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
print(state["score"]) # -> 0
print(state["game_over"]) # -> False

state = create_game_state(size=5)
print(state["grid"]) # -> [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]

33.2. Step 1.2: Adding Random Tiles#

Goal: Implement functions to add a new tile. Remember to correctly organized your source code. Please ensure you adhere to the function parameters and expected return values. Everything is specified in the comment at the beginning of the function. Please keep it in your code; it is part of the documentation.

To Implement: Implement the following functions to add a tile.

Hint: Becarefull and keep the same order to identify a cell. I recommand first, the line, and then the column. So grid[0][2] means the tile in the first line and the last column.

import random

def get_empty_cells(grid):
    """
    Returns the list of coordinates of empty cells.
    
    Args:
        grid (list): Game grid
    
    Returns:
        list: List of tuples (row, column)
    """
    pass

def add_random_tile(state):
    """
    Add a random tile (2 or 4) to an empty cell in the grid.

    Args:
        state (dict): Current game state

    Returns:
        bool: True if a tile was added, False otherwise
    """
    pass

def update_score(state):
    """
    Updates the score in the game state. The score is the sum of all tiles.

    Args:
        state (dict): Current game state

    Returns:
        None
    """
    

Tests to Perform:

state = create_game_state()
print(state["grid"])  # Should display a grid with all zeros
b = add_random_tile(state)
print(b)  # Should display True
print(state["grid"])  # Should display a grid with one random tile (2 or 4)
print("---")

b = add_random_tile(state)
print(b)  # Should display True
print(state["grid"])  # Should display two random tiles
print("---")

# Count non-zero tiles
count = sum(1 for row in state["grid"] for cell in row if cell != 0)
print(f"Number of tiles: {count}")  # Should display 2
print("---")


state["grid"] = [[2, 4, 2, 4],
                 [4, 2, 4, 2],
                 [2, 4, 2, 4],
                 [4, 2, 4, 2]]
print(state["grid"])  # Filled random grid
b = add_random_tile(state)
print(state["grid"])  # Should display the same grid
print(b)  # Should display False

print("---")
update_score(state)
print(state["score"]) # Should display 48

33.3. Step 1.3: Merging a Line to the Left#

Goal: Implement the logic for moving and merging a single line.

Suggested Algorithm:

  • Extract non-zero values

  • Merge adjacent identical values

  • Fill with zeros on the right

To Implement:

def merge_line_left(line):
    """
    Moves and merges tiles in a line to the left.
    
    Args:
        line (list): A grid line
    
    Returns:
        tuple: new_line
    
    Example:
        [2, 0, 2, 4] -> [4, 4, 0, 0]
        [2, 2, 4, 4] -> [4, 8, 0, 0]
    """
    pass

Tests to Perform:

# Test 3: Line merging
result = merge_line_left([2, 0, 2, 4])
print(result)  # [4, 4, 0, 0]

result = merge_line_left([2, 2, 4, 4])
print(result)  # [4, 8, 0, 0]

result = merge_line_left([2, 4, 8, 16])
print(result)  # [2, 4, 8, 16]

result = merge_line_left([0, 0, 0, 0])
print(result)  # [0, 0, 0, 0]

33.4. Step 1.4: Grid Transformations#

Goal: Implement utility functions to manipulate the grid.

To Implement:

def transpose_grid(grid):
    """
    Returns the transpose of the grid.
    
    Args:
        grid (list): Grid to transpose
    
    Returns:
        list: Transposed grid
    """
    pass

def reverse_grid_rows(grid):
    """
    Returns a grid with reversed rows.
    
    Args:
        grid (list): Original grid
    
    Returns:
        list: Grid with reversed rows
    """
    pass

Tests to perform:

# Test 4: Transformations
grid = [[1, 2, 3, 4],
        [5, 6, 7, 8],
        [9, 10, 11, 12],
        [13, 14, 15, 16]]

transposed = transpose_grid(grid)
print(transposed)  # [[1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15], [4, 8, 12, 16]]

reversed_grid = reverse_grid_rows(grid)
print(reversed_grid)  # [[4, 3, 2, 1], [8, 7, 6, 5], [12, 11, 10, 9], [16, 15, 14, 13]]

33.5. Step 1.5: Movements in 4 Directions#

Goal: Implement movement functions for each direction.

Tips:

  • For move_right: reverse rows, apply move_left, reverse again

  • For move_up: transpose, apply move_left, transpose again

  • For move_down: transpose, apply move_right, transpose again

To Implement:

def move_left(state):
    """
    Returns a new state after moving left.
    
    Args:
        state (dict): Current game state
    
    Returns:
        dict: New state after the move
    """
    pass

def move_right(state):
    """Returns a new state after moving right."""
    pass

def move_up(state):
    """Returns a new state after moving up."""
    pass

def move_down(state):
    """Returns a new state after moving down."""
    pass

Tests to perform:

# Test 5: Movements
state = create_game_state()
state['grid'] = [
    [2, 0, 2, 0],
    [4, 4, 0, 0],
    [0, 0, 8, 8],
    [2, 2, 2, 2]
]
print(f"Initial grid:    {state['grid']}")
b = move_left(state)
print(f"Grid after left: {state['grid']}") # [[4, 0, 0, 0], [8, 0, 0, 0], [16, 0, 0, 0], [4, 4, 0, 0]]
print(f"Score: {state['score']}") # 36
print(f"Grid has changed: {b}") # True
print("---")

print(f"Initial grid:     {state['grid']}")
b = move_right(state)
print(f"Grid after right: {state['grid']}") # [[0, 0, 0, 4], [0, 0, 0, 8], [0, 0, 0, 16], [0, 0, 0, 8]]
print(f"Score: {state['score']}") # 36
print(f"Grid has changed: {b}") # True
print("---")


state['grid'] = [
    [0, 0, 2, 2],
    [4, 4, 2, 4],
    [0, 0, 8, 8],
    [2, 4, 2, 2]
]
print(f"Initial grid:  {state['grid']}")
b = move_up(state)
print(f"Grid after up: {state['grid']}") # [[4, 8, 4, 2], [2, 0, 8, 4], [0, 0, 2, 8], [0, 0, 0, 2]]
print(f"Score: {state['score']}") # 44
print(f"Grid has changed: {b}") # True
print("---")

print(f"Initial grid:    {state['grid']}")
b = move_down(state)
print(f"Grid after down: {state['grid']}") # [[0, 0, 0, 2], [0, 0, 4, 4], [4, 0, 8, 8], [2, 8, 2, 2]]
print(f"Score: {state['score']}") # 44
print(f"Grid has changed: {b}") # True
print("---")



state["grid"] = [
    [2, 4, 2, 4],
    [4, 2, 4, 2],
    [2, 4, 2, 4],
    [4, 2, 4, 2]
]
print(f"Initial grid:    {state['grid']}")
b = move_left(state)
print(f"Grid after left: {state['grid']}") # [[0, 0, 0, 2], [0, 0, 4, 4], [4, 0, 8, 8], [2, 8, 2, 2]]
print(f"Grid has changed: {b}") # False
print("---")

33.6. Step 1.6: End Game Detection#

Goal: Detect when no moves are possible.

To Implement:

def can_move(grid):
    """
    Checks if at least one move is possible.
    
    Args:
        grid (list): Grid to check
    
    Returns:
        bool: True if a move is possible, False otherwise
    """
    pass

def update_game_over(state):
    """
    Returns a new state with game_over updated.
    
    Args:
        state (dict): Current state
    
    Returns:
        dict: New state with updated game_over
    """
    pass

Tests to perform:


# Test 6: End detection
grid_with_moves = [
    [2, 4, 2, 4],
    [4, 2, 4, 2],
    [2, 4, 2, 4],
    [4, 2, 4, 0]  # Empty cell
]
print(can_move(grid_with_moves))  # True

grid_no_moves = [
    [2, 4, 2, 4],
    [4, 2, 4, 2],
    [2, 4, 2, 4],
    [4, 2, 4, 2]
]
print(can_move(grid_no_moves))  # False

grid_with_vertical_moves = [
    [4, 8, 2, 8],
    [4, 2, 8, 2],
    [2, 8, 2, 8],
    [8, 2, 8, 2]
]
print(can_move(grid_with_vertical_moves))  # True

grid_with_horizontal_moves = [
    [4, 4, 2, 8],
    [8, 2, 8, 2],
    [2, 8, 2, 8],
    [8, 2, 8, 2]
]
print(can_move(grid_with_horizontal_moves))  # True

33.7. Step 1.7: Main Game Function#

Goal: Encapsulate all the logic for one move.

To Implement:

def play_move(state, direction):
    """
    Performs a move in the given direction.
    
    Args:
        state (dict): Current game state
        direction (str): 'up', 'down', 'left', 'right'
    
    Returns:
        dict: New state after the move
    """
    # 1. Perform the move according to direction
    # 2. If the grid has changed:
    #    - Add a new tile
    #    - Check game over
    # 3. Return the new state
    pass

Tests to perform:

# Test 7: Complete game
state = create_game_state()
state["grid"] = [
    [4, 4, 2, 8],
    [8, 2, 8, 2],
    [2, 8, 2, 8],
    [8, 2, 8, 2]
]

print("Initial state:")
print(state["grid"])


play_move(state, 'left')
print("\nAfter left move:")
print(state["grid"]) # Should be: [[8, 2, 8, X], [8, 2, 8, 2], [2, 8, 2, 8], [8, 2, 8, 2]] with x = 2 or 4
print(f"Game Over: {state['game_over']}") # Should be False

34. Part 2: The View with rich and readchar#

From this step, you will need to use a terminal to run the program. In other case, the command from rich and readchar libraries will not work.

34.1. Step 2.1: Install the libraries#

With Thonny, in the tools menu, you can open a configured terminal using open system shell. In the terminal, launch the two following commands to install the libraries.

pip3 install rich
pip3 install readchar

Create a view.py module containing the following import instructions.

import rich.box
from rich.console import Console
from rich.table import Table
from rich.text import Text
import readchar

Open a terminal and launch the command python3 view.py. If there is no error, then the module are correctly installed.

34.2. Step 2.2: Read a character from the keyboard#

In the module view.py add the following function and test it.


def get_player_input():
    """
    Waits for and returns player input.

    Returns:
        str: Direction ('up', 'down', 'left', 'right') or 'quit'
    """
    try:
        key = readchar.readkey()

        # Map keys to directions
        key_mapping = {
            readchar.key.UP: 'up',
            readchar.key.DOWN: 'down',
            readchar.key.LEFT: 'left',
            readchar.key.RIGHT: 'right',
            'q': 'quit',
            'Q': 'quit'
        }

        return key_mapping.get(key, None)

    except:
        return None


def test_input():
    """
    Waits for an arrow key until "q" or "Q" is input.
    """
    k = get_player_input()
    while k != "quit":
        print(k)
        k = get_player_input()
    print(k)

34.3. Step 2.3: Grid Display#

We provide functions to help you to represent the grid using rich module. Copy/paste the following code into the view.py module.

# Color palette for tiles
TILE_STYLES = {
    0: "dim white on grey23",  # Empty
    2: "black on grey78",  # 2
    4: "black on grey74",  # 4
    8: "white on dark_orange",  # 8
    16: "white on orange1",  # 16
    32: "white on red",  # 32
    64: "white on red3",  # 64
    128: "black on yellow",  # 128
    256: "black on gold1",  # 256
    512: "black on yellow1",  # 512
    1024: "black on gold3",  # 1024
    2048: "white on yellow1 bold",  # 2048
}


def create_console():
    """
    Creates a Rich console instance.

    Returns:
        Console: Console instance
    """
    return Console()


def get_tile_style(value):
    """
    Returns the style (color, background) for a tile value.

    Args:
        value (int): Tile value

    Returns:
        str: Rich style string
    """
    if value not in TILE_STYLES:
        # For values > 2048
        return "white on magenta bold"
    return TILE_STYLES[value]


def create_grid_table(state):
    """
    Creates a Rich Table representing the game grid.

    Args:
        state (dict): Game state

    Returns:
        Table: Styled Rich table
    """

    grid = state['grid']

    # Create table without headers
    table = Table(show_header=False, show_edge=True, show_lines=True, pad_edge=False,
                  box=rich.box.HEAVY_EDGE, padding=(0, 0))

    # Add columns
    for _ in range(len(grid)):
        table.add_column(justify="center", width=6)

    # Add rows with styled tiles
    for row in grid:
        styled_cells = []
        for value in row:
            if value == 0:
                # Empty cell
                cell = Text("·", style=get_tile_style(0))
            else:
                # Tile with value
                cell = Text(str(value), style=get_tile_style(value))
            styled_cells.append(cell)

        table.add_row(*styled_cells)

    return table

Tests to perform:

The following function allow you to create a test grid and display it on a terminal. Remember, you should use a terminal to execute your programm. Do not use the run current script buton (green arrow).

def test_grid_display():
    """
    Test function to display a sample grid.
    """
    state = {
        'grid': [
            [2, 0, 0, 2],
            [4, 4, 0, 0],
            [0, 0, 8, 8],
            [16, 0, 0, 16]
        ],
        'score': 0,
        'game_over': False
    }

    console = create_console() # Create a console instance
    table = create_grid_table(state) # Create the grid table
    console.clear() # Clear the console
    console.print(table) # Print the table to the console 

You should see something like that.

Capture d’écran 2025-12-01 à 19.34.26.png

35. Part 3: The Controller (Interaction Management)#

You have now all functionalities to implement the controller of the game.

35.1. Step 3.1: Keyboard Input Management with Blessed#

Goal: Create a controller.py module and use functions from model and view to implement the game.

To Implement:

import model
import view

def game_loop():
    """
    Main game loop for 2048. Create a new game state, add two initial tiles,
    and repeatedly get player input to play moves until the game is over. Then quits and display the player score.

    :return:
    """
    pass
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[8], line 1
----> 1 import model
      2 import view
      4 def game_loop():

ModuleNotFoundError: No module named 'model'

36. Part 4: Show your skills#

Your project is now complete in its minimal version.

You can now show your capabilities. You can implement any new features you choose. For example:

  • The ability to quit and resume a game later with a save file.

  • A high score table that is saved between launches.

  • A graphical interface using Pygame.

  • An AI that plays for you.

However, here are a few constraints:

  • The model/view/controller structure must be maintained.

  • The model must not change unless a specific reason is provided. It can, however, be expanded.

  • Documentation in the form of a README.txt file must document and explain the features you have chosen to implement.

At the end of your work, you will upload the directory containing all your source code and documentation in ZIP format (only zip, no 7zip or rar, or anything else). You will find on EDUNAO a link to upload your project. The deadline is the 4th january 2026 at 23h59.